iT邦幫忙

2023 iThome 鐵人賽

DAY 27
0

middleware 中間層簡介

在ASP.NET的middleware如下:
aspnet core middleware

圖片來源:https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/

而在rust裡因為不是物件導向的概念,而我們使用的warp又比較接近函數式的寫法,又要再提到鐵道開發法了:
鐵道開發法示意圖

圖片來源:https://www.slideshare.net/ScottWlaschin/railway-oriented-programming

上面那條線是 Success ,下面那條路是 Failure,寫到這邊是不是有印象一直看到:

impl Filter<Extract=(impl warp::Reply, ), Error=Rejection> 

Filter就是warp裡實作的通道,Extract就是上面的通道,Error就是走下面的通道,而這個Extract還可以往後面的通道加一點料,後面的範例會提及。impl warp::Reply就是最後會轉成http Response的東西,而Rejection就是放置錯誤用的物件。回憶一下在第10篇我們使用warp的recover 去接 rejection,並把rejection轉為我們要回應的response傳給API呼叫者:

// web/src/error.rs
pub async fn handle_rejection(err: Rejection) 
    -> Result<impl Reply, Infallible> {
    // ... 略
    Ok(warp::reply::with_status(json, code))
}
// web/src/routers.rs
pub fn all_routers(ctx: AppContext)
    -> impl Filter<Extract=impl Reply, Error=Rejection> + Clone {
    // ...略
    hello
        .or(static_files)
        .or(ws_routers(ctx.clone()))
        .or(api_games)
        .recover(error::handle_rejection)
}

對照上面的程式碼和下面的鐵道圖,是不是有比較清晰一點了。因為我們http api最終還是要回應給http client,所以最後需要再針對Rejection進行處理,轉成對應的Http Status Code和內容,有別於開頭C#的middleware圖,在Response時會再遍歷每一層middleware,在鐵道中就是單行道,所以當發生錯誤時,就走下面的快速通道,直達最後。這也是為什麼我們過去常常要寫錯誤的轉換:impl From<ErrorA> for ErrorB。許多外部套件都有使用自己的Error類別,而我們的程式也有我們自己的Error類別,像Rejection就是一種Error類別,我們之前在寫gRPC用的tonic套件也有Error類別Status

完整的鐵道開發流程圖

圖片來源:https://www.slideshare.net/ScottWlaschin/railway-oriented-programming

Middleware在我們寫rust的時候比較像上圖的鐵道,只要Input型別與前一手的Output型別一樣,Output型別與下一手的Input型別一樣,就可以安插進去,如果整路的鐵道Input/Output類型都相同,那麼就可以很容易的抽換,在FP中,也很容易對每一段獨立進行單元測試。這個概念理解後,對於在rust中使用Option和Result會更得心應手。

加 Auth Layer

加 auth layer之前,講一下Role-based access control,以角色作為權限控制的單位,定義角色擁有什麼權限,而使用者可分配其歸屬的角色為何:

角色基礎權限控制示意圖

我們依以上的圖,簡略地開一下相關的結構體:

// core/src/user.rs
use serde::{Deserialize, Serialize};

pub struct User {
    pub name: String,
}

pub struct Role {
    pub name: String,
    pub users_name: Vec<String>,
    pub permissions: Vec<Permission>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Permission {
    Admin = 0,
    GameCreate = 1,
    GamePlay = 2,
    GameDelete = 3,
}

// core/src/lib.rs
pub mod user;

這邊可以看到rust的enum可以給予整數編號,不指定的話rust預設會使用isize幫我們針對enum裡的每一個變體(Variant)給予一個獨立的編號。而我們也可以手動給予編號,在這裡的考量是:Permission最終還是會儲存在db裡,如果用String的型式就像JSON一樣,那麼調整enum變體的順序也沒關係;但如果序列化存成數字儲存,那麼改變順序的話,從db讀出來就會產生問題。所以在這邊我們才要明確的宣示,告訴rust我們要指定每一個variant使用哪個數字作為rust程式執行時的內部編碼。

這裡要寫權限驗證,就先跳過user/role/permission的增刪查改實作,直接做一個假的權限查詢器:

// core/src/user.rs
#[deprecated(note = "this is not for production use")]
pub fn fake_query_user_permissions(user_name: String) -> Vec<Permission> {
    match user_name.as_str() {
        "admin" => vec![Permission::Admin, Permission::GameCreate, Permission::GamePlay, Permission::GameDelete],
        "game" => vec![Permission::GameCreate, Permission::GamePlay],
        _ => vec![],
    }
}

除了它是假的,這裡fn上面還有一個deprecated的annotation標註,這是什麼作用呢,就是在compile的時候抱怨,提醒這個fn要被棄用了,除了note外還可以下 since 預告哪一個版本之後將移除此fn,如果在寫比較底層的api供別人使用。這個在做版本管理滿實用的,讓別人可以及早因應。而除了編譯的抱怨,IDE也會逼得我們不得不注意到:

compiler:
compiler 提示使用到已廢棄的fn

VS Code:
VS Code 提示使用到已廢棄的fn

RustRover
RustRover 提示使用到已廢棄的fn

其實deprecated各程式語言都有相對應的用法:C# ObselteJava DeprecatedTypeScript deprecated

以下我們先實作登入產生token,再實作呼叫api帶JWT如何驗證權限。

實作 Login 登入 API

還是上面那張鐵道圖來理解,request請求打進來,我們要解析http request body的內容,就要加一個function處理,每一個and()裡包的都是一個小型的middleware,先看code再說明:

// web/src/auth.rs
use std::{env, ops::Add};
use warp::{Filter, Rejection, Reply};
use my_core::user::{fake_query_user_permissions};

pub fn login() -> impl Filter<Extract=(impl Reply, ), Error=Rejection> + Clone {
    warp::path!("login")
        .and(warp::post())
        .and(warp::body::content_length_limit(1024 * 16))
        .and(warp::body::json::<LoginRequest>())    
        .and_then(login_handler)
}

#[derive(Debug, Clone, Deserialize)]
pub struct LoginRequest {            // Request Dto
    pub username: String,
    pub password: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct LoginResponse {           // Response Dto
    pub access_token: String,
}
  • warp::body::content_length_limit:這個鐵道部件是warp幫我們做好的(好有小時候玩鐵道組的既視感),接上去會判斷http請求的header帶的Content-Length多少,超過的就會拒絕此請求,傳出Rejection。(此項目不是必填)。
  • warp::body::json:這個fn會幫我們判斷請求檔頭是不是有Content-Type: application/json,不是就拒絕此請求,是的話就試著把body的json內容解析為我們指定的類別T,並傳給下一組鐵道組件。
  • LoginRequest/LoginResponse:請求/回應的 DTO

接下來處理接到request的邏輯:

pub async fn login_handler(req: LoginRequest)  // 由上一個部件傳入
    -> Result<impl Reply, Rejection> {
    let sub = req.username;
    if sub == "guest" {    // todo: 待實作驗 user 帳密 (pass hash)
        return Err(Rejection::from(      // 模擬登入失敗
            AppError::BadRequest("登入失敗".to_string())))
    }                                    
    let permissions = fake_query_user_permissions(sub.clone());
    let permissions: Vec<u16> = permissions
        .iter()
        .map(|p| p.clone().into())
        .collect::<Vec<_>>();            // 把 permission 轉整數
    let exp = Utc::now()
        .add(chrono::Duration::hours(8)) // 加8小時到期
        .timestamp();
    let claim = Claims {                 // 產jwt用的payload
        sub,
        exp,
        permissions,
    };
    let access_token = generate_jwt(key(), claim)?;
    let response = LoginResponse { access_token };
    Ok::<_, Rejection>(warp::reply::json(&response))
}

這裡我們login_handler加一個參數類別是LoginRequest,這參數必需是從前一個鐵道部分傳過來的,不然會報錯,前後銜接要相同類別才能接的起來,最後再把運算的結果轉成回應DTO,並使用warp::reply::json把資料序列化為JSON。

最後把這一整組軌道部件加到我們的router裡:

// web/src/routers.rs
use crate::auth::login;

pub fn all_routers(ctx: AppContext) 
    // ... 略    
    hello
        .or(login())
    // ... 略

實測一下:

一般用戶成功登入,權限為空:
一般用戶登入的API結果
一般用戶登入JWT解析結果

ADMIN 登入,含所有權限:
ADMIN登入的API結果
ADMIN登入JWT解析結果

登入失敗:
登入失敗的API結果

實現API權限查驗

依照剛剛做鐵道部件的經驗,加一個解析token的fn,並把解析完的結果,放入一個叫CurrentUser的結構體:

// web/src/auth.rs
use warp::header::headers_cloned;
use warp::http::{HeaderMap, HeaderValue};
use my_core::user:: Permission;

pub fn with_auth() // ↓↓ Extract取出來的類別CurrentUser當成後面的參數
    -> impl Filter<Extract=(CurrentUser, ), Error=Rejection> + Clone {
    headers_cloned().and_then(|headers: HeaderMap<HeaderValue>|
        async move {
        let auth_header = headers.get("Authorization");
        match auth_header {
            None => {
                return Ok::<_, Rejection>(CurrentUser::Anonymous)
            }
            Some(auth_header) => {
                let token = auth_header.to_str().unwrap().to_string();
                let jwt = token.replace("Bearer ", "");
                let claims = verify_jwt(key(), jwt)?;
                let permissions = claims.permissions;
                let name = claims.sub;
                Ok::<_, Rejection>(
                    CurrentUser::User { name, permissions })
            }
        }
    })
}

#[derive(Debug, Clone)]
pub enum CurrentUser {
    Anonymous,                    // 匿名使用者(無登入資訊)
    User {                        // 使用者
        name: String,             // 使用者帳號
        permissions: Vec::<u16>,  // 使用者權限清單
    },
}

這邊加一個CurrentUser結構體,用來存放當前使用者的資料,在with_auth中我們使用warp提供的組件headers_cloned(),會試著解析請求的header,並當參數傳出,所以我們後面的and_then就要接收這個header,裡面就解析header裡的Authorization欄位,並把解析的結果存入CurrentUser,遞交給下一棒。

and只是加料,and_then才會接受累加的參數,而and_then裡面要放一個Future,所以我們加上async讓閉包回傳一個Future。而取得JWT驗出來使用者資訊後,接下來我們要做一個軌道,用來以後針對個別API檢驗權限使用(大家開始除了組積木外,也會自己造積木了):

/// 檢查該使用者是不是擁有指定權限
pub fn check_permission(user: CurrentUser, permission: Permission)
    -> Result<(), Rejection> {
    let permission: u16 = permission.into();
    match user {
        CurrentUser::Anonymous => {
            Err(warp::reject::custom(AppError::Unauthorized))
        }
        CurrentUser::User { name, permissions } => {
            if permissions.iter().any(|&p| p == permission) {
                Ok::<_, Rejection>(())
            } else {
                Err(warp::reject::custom(AppError::Unauthorized))
            }
        }
    }
}

上面只是單純檢核指定的User有沒有包含指定的權限Permission,使用iter().any()進行搜索,成功回傳(),失敗則傳出我們定義的AppError::Unauthorized,並透過warp轉為Rejection。以下是我們最終版的積木:

pub fn with_permission(permission: Permission)
    -> impl Filter<Extract=(), Error=Rejection> + Clone {
    with_auth()
        .and_then(move |user: CurrentUser| {
            let p = permission.clone();
            async {
                let result = check_permission(user, p);
                match result {
                    Ok(_) => {
                        Ok::<_, Rejection>(())
                    }
                    Err(_) => {
                        Err(warp::reject::custom(
                            AppError::Unauthorized))
                    }
                }
            }
    }).untuple_one()
}

最後這塊積木我們讓它Extract回傳(),就表示我們沒有在後面的管道添加新的東西,因為只要一加了東西,我們先前寫的api,就要添加對應的參數。比如前面的with_auth()只要被套用,後面處理的handler就要再多接一個CurrentUser的參數,這樣好像會讓程式變的不太彈性,所以with_permission最後接一個untuple_one,把我們裡面的結果再凹成沒有回傳,就是不往後面的軌道裡加料。

一樣在and_then裡因為要回傳一個Future,所以加async,而這裡的所有權限核機制,參數permission不允許被move進閉包,所以我們在閉包裡把permission克隆(clone)一份,保留不移動外面的所有權。

最後怎麼用這塊積木呢,我們直接接在hello上實驗一下:

// web/src/routers.rs
use my_core::user::Permission;
use crate::auth::{login, with_permission};

pub fn all_routers(ctx: AppContext)
    // ... 略
    let hello = warp::path("hello")
        .and(warp::get())
        .and(with_permission(Permission::Admin))    // 加這行
        .map(|| {
            tracing::info!("saying hello...");
            "Hello, World!"
        });
    // ... 略

實測一下,利用剛剛產出的JWT,帶入API中:

ADMIN:
使用ADMIN呼叫API正確顯示結果
非ADMIN:
使用非ADMIN呼叫API,會正確回傳未授權的提示

middleware tracing

雖然warp有實作trace request,我們在第9篇就加到我們的專案裡了,不過這邊可以看一下如果我們要自己做的話大約是怎麼實現的,順便再熟悉一下剛剛鐵道的加料方法:

use std::net::SocketAddr;
use warp::http::{HeaderMap, Method};
use warp::hyper::body::Bytes;
use warp::path::FullPath;

pub fn all_routers(ctx: AppContext) {
    // ... 略
    let hello = warp::path("hello")
        .and(warp::get())
        .and(with_permission(Permission::Admin))
        .and(tracing())        // 加這
        .map(|| {
            tracing::info!("saying hello...");
            "Hello, World!"
        });
    // ...略
}

fn tracing() -> impl Filter<Extract=(), Error=Rejection> + Clone {
    warp::addr::remote()
        .and(warp::header::headers_cloned())
        .and(warp::method())
        .and(warp::path::full())
        .and(warp::query::raw())
        .and(warp::body::bytes())
        .and_then(|addr:Option<SocketAddr> , headers:HeaderMap, method:Method, path:FullPath, query: String, body:Bytes| async move {
            let query = query.to_string();
            let body = String::from_utf8(body.to_vec()).unwrap_or_default();
            tracing::warn!(
                "addr: {:?}\nmethod: {:?}\npath: {:?}\nquery: {:?}\nheaders: {:?}\nbody: {:?}",
                addr,
                method,
                path,
                query,
                headers,
                body
            );
            Ok::<(), Rejection>(())
        })
        .untuple_one()
}

可以看到這裡我們加了很多料,所以在and_then()在接的時候,就要把上面一連串加的東西全部都帶進來,不加或漏加的話會報錯,而最後我們依然使用untuple_one,讓一切塵歸塵土歸土(?),悄悄地來悄悄地走,不留下一片雲彩(?),喔我可能累了,就是最後不產生副作用去影響後續的handler,這邊幾乎吧request可以取得的資料全用上了,如果有需要可以自己挑去使用。

實測一下

呼叫後端API:
呼叫後端API內容

呼叫後端輸出內容

小品 IPv6

只要改以下,我們就可以使用IPv6的協議連線了:

@@ web/src/main.rs @@
+use std::net::IpAddr;
+use std::str::FromStr;

+let addr = IpAddr::from_str("::0").unwrap();
+warp::serve(routers).run((addr, config::http_port())).await;
-warp::serve(routers).run(([0, 0, 0, 0], config::http_port())).await;

使用IPv4 連線:
以IPv4開啟
後端接到IPv4 的訊息:
後端接到IPv4 的訊息
使用IPv6連線:
以IPv6開啟
後端接到IPv6 的訊息:
後端接到IPv6 的訊息
不過這個方式依這裡面的說明會依不同的OS而有所不同,不總是可以作用,另一種方式是開多個port同時跑,我們已經學過tokio的分身之術,在這裡就可以直接開起來:

let addr_v6 = IpAddr::from_str("::0").unwrap();
let addr_v4 = [0,0,0,0];
tokio::join!(
    warp::serve(routers.clone()).run((addr_v4, config::http_port())),
    warp::serve(routers.clone())
        .tls()
        .cert_path(config::tls_cert_path())
        .key_path(config::tls_key_path())
        .run((addr_v4, config::https_port())),
    warp::serve(routers.clone()).run((addr_v6, 3036)),
    warp::serve(routers.clone())
        .tls()
        .cert_path(config::tls_cert_path())
        .key_path(config::tls_key_path())
        .run((addr_v6, 3037)),
);

上例我們使用tokio的join巨集,把ipv4, ipv6, http, https等服務跑在同一個執行檔裡。

同時監聽多個port

本系列專案源始碼放置於 https://github.com/kenstt/demo-app


上一篇
26 用 JWT 實現 rust Auth
下一篇
28 前端授權與驗證
系列文
前端? 後端? 摻在一起做成全端就好了30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言